package com.data.hijack.http;

import java.io.*;
import java.net.InetSocketAddress;
import java.net.URLDecoder;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.regex.Matcher;

import org.apache.log4j.Logger;

import com.data.hijack.constant.Constants;
import com.data.hijack.util.AppUtils;
import com.data.hijack.util.StringUtils;
import com.data.hijack.util.TextUtils;

/**
 * @auther husq
 */
public class HttpSession {
    private static final Logger logger = Logger.getLogger(HttpSession.class);
    private static final int REQUEST_BUFFER_LEN = 512;

    private static final int MEMORY_STORE_LIMIT = 1024;

    private static final int BUFSIZE = 8192;

    private static final int MAX_HEADER_SIZE = 1024;

    private SocketChannel channel;
    private ITempFileManager tempFileManager;

    private int splitbyte;

    private int rlen;

    private String uri;

    private Method method;

    private Map<String, List<String>> parms;

    private Map<String, String> headers;

    private CookieHandler cookies;

    private String queryParameterString;
    private ContentType contentType;

    private String remoteIp;

    private String remoteHostname;

    private String protocolVersion;

    private boolean keepAlive;

    private ByteBuffer multipart;

    private Map<String, String> multipartFileContentType;

    private byte[] preBuff = new byte[HttpSession.BUFSIZE];
    private String charset = Constants.DEFAULT_CHARSET;

    public HttpSession(String tmpDir) {
        this.tempFileManager = new DefaultTempFileManager(tmpDir);
    }

    public HttpSession(ITempFileManager tempFileManager, String tmpDir) {
        if (null == tempFileManager) {
            this.tempFileManager = new DefaultTempFileManager(tmpDir);
        } else {
            this.tempFileManager = tempFileManager;
        }
    }

    public int preHandle() throws Exception {
        ByteBuffer prebb = ByteBuffer.allocate(HttpSession.BUFSIZE);
        this.splitbyte = 0;
        this.rlen = 0;
        prebb.clear();

        int len = channel.read(prebb);
        while (len == 0) {
            TimeUnit.MILLISECONDS.sleep(20);
            len = channel.read(prebb);
        }

        if (len == -1) {
            logger.warn(TextUtils.format("%s", HttpConstants.STREAM_SHUTDOWN));
            return -1;
        }

        byte[] buf = prebb.array();
        int prerLen = len;
        System.arraycopy(buf, 0, preBuff, 0, prerLen);
        while (prerLen > 0) {
            this.rlen += prerLen;
            int fd = findHeaderEnd(buf, prerLen);
            if (fd == 0) {
                this.splitbyte += buf.length;
            } else if (fd > 0) {
                this.splitbyte += fd;
                break;
            }
            prebb.clear();
            int readLen = channel.read(prebb);
            if (readLen > 0) {
                buf = prebb.array();
                prerLen = readLen;
                preBuff = Arrays.copyOf(preBuff, preBuff.length + prerLen);
                System.arraycopy(buf, 0, preBuff, preBuff.length - prerLen, prerLen);
            }
        }

        this.parms = new HashMap<String, List<String>>();
        if (null == this.headers) {
            this.headers = new HashMap<String, String>();
        } else {
            this.headers.clear();
        }

        BufferedReader hin =
            new BufferedReader(new InputStreamReader(new ByteArrayInputStream(this.preBuff, 0, this.rlen)));

        Map<String, String> pre = new HashMap<String, String>();
        decodeHeader(hin, pre, this.parms, this.headers);

        InetSocketAddress isa = (InetSocketAddress)channel.getRemoteAddress();
        this.remoteIp = isa.getAddress().getHostAddress();
        if (null != this.remoteIp) {
            this.headers.put("remote-addr", this.remoteIp);
            this.headers.put("http-client-ip", this.remoteIp);
        }

        this.contentType = new ContentType(this.headers.get("content-type"));

        this.method = Method.lookup(pre.get("method"));
        if (this.method == null) {
            throw new ResponseException(Status.BAD_REQUEST,
                "BAD REQUEST: Syntax error. HTTP verb " + pre.get("method") + " unhandled.");
        }

        this.uri = pre.get("uri");

        this.cookies = new CookieHandler(this.headers);

        String connection = this.headers.get("connection");
        this.keepAlive =
            "HTTP/1.1".equals(protocolVersion) && (connection == null || !connection.matches("(?i).*close.*"));

        return 0;
    }

    public void parseBody(Map<String, String> files) throws IOException, ResponseException {
        RandomAccessFile randomAccessFile = null;
        try {
            int size = getBodySize();
            ByteArrayOutputStream baos = null;
            DataOutput requestDataOutput = null;

            if (size < MEMORY_STORE_LIMIT) {
                baos = new ByteArrayOutputStream();
                requestDataOutput = new DataOutputStream(baos);
            } else {
                randomAccessFile = getTmpBucket();
                requestDataOutput = randomAccessFile;
            }

            int bblen = (int)Math.min(size, REQUEST_BUFFER_LEN);
            long preLen = preBuff.length;
            long total = this.splitbyte + size;
            if (preLen <= total) {
                ByteBuffer bb = ByteBuffer.allocate(bblen);
                long preBody = preLen - this.splitbyte;
                while (size > preBody) {
                    bb.clear();
                    int rlen = this.channel.read(bb);
                    preBody += rlen;
                    if (rlen > 0) {
                        byte[] b = bb.array();
                        preBuff = Arrays.copyOf(preBuff, preBuff.length + rlen);
                        System.arraycopy(b, 0, preBuff, preBuff.length - rlen, rlen);
                    }
                }
            }
            if (size > 0) {
                byte[] bodyByte = new byte[size];
                System.arraycopy(preBuff, this.splitbyte, bodyByte, 0, size);
                requestDataOutput.write(bodyByte);
            }

            ByteBuffer fbuf = null;
            if (baos != null) {
                fbuf = ByteBuffer.wrap(baos.toByteArray(), 0, baos.size());
            } else {
                fbuf = randomAccessFile.getChannel().map(FileChannel.MapMode.READ_ONLY, 0, randomAccessFile.length());
                randomAccessFile.seek(0);
            }

            if (Method.POST.equals(this.method)) {
                this.contentType = new ContentType(this.headers.get("content-type"));
                if (contentType.isMultipart()) {
                    String boundary = contentType.getBoundary();
                    if (boundary == null) {
                        throw new ResponseException(Status.BAD_REQUEST,
                            "BAD REQUEST: Content type is multipart/form-data but boundary missing. Usage: GET /example/file.html");
                    }

                    multipart = fbuf;
                    decodeMultipartFormData(contentType, fbuf, this.parms, files);
                } else {
                    byte[] postBytes = new byte[fbuf.remaining()];
                    fbuf.get(postBytes);
                    String charset = StringUtils.isBlank(this.charset) ? contentType.getEncoding() : this.charset;
                    String postLine = new String(postBytes, charset).trim();
                    if ("application/x-www-form-urlencoded".equalsIgnoreCase(contentType.getContentType())) {
                        decodeParms(postLine, this.parms);
                    } else if (postLine.length() != 0) {
                        files.put(HttpConstants.HTTP_POST_DATA_KEY, postLine);
                    }
                }
            } else if (Method.PUT.equals(this.method)) {
                files.put("content", saveTmpFile(fbuf, 0, fbuf.limit(), null));
            }
        } finally {
            AppUtils.safeClose(randomAccessFile);
        }
    }

    private RandomAccessFile getTmpBucket() {
        try {
            ITempFile tempFile = this.tempFileManager.createTempFile(null);
            return new RandomAccessFile(tempFile.getName(), "rw");
        } catch (Exception e) {
            throw new Error(e); // we won't recover, so throw an error
        }
    }

    private int getBodySize() {
        if (this.headers.containsKey("content-length")) {
            return Integer.parseInt(this.headers.get("content-length"));
        } else if (this.splitbyte < this.rlen) {
            return this.rlen - this.splitbyte;
        }
        return 0;
    }

    private int findHeaderEnd(final byte[] buf, int rlen) {
        int splitbyte = 0;
        while (splitbyte + 1 < rlen) {
            // RFC2616
            if (buf[splitbyte] == '\r' && buf[splitbyte + 1] == '\n' && splitbyte + 3 < rlen
                && buf[splitbyte + 2] == '\r' && buf[splitbyte + 3] == '\n') {
                return splitbyte + 4;
            }

            // tolerance
            if (buf[splitbyte] == '\n' && buf[splitbyte + 1] == '\n') {
                return splitbyte + 2;
            }
            splitbyte++;
        }
        return 0;
    }

    private String saveTmpFile(ByteBuffer b, int offset, int len, String filename_hint) {
        String path = "";
        if (len > 0) {
            FileOutputStream fileOutputStream = null;
            try {
                ITempFile tempFile = this.tempFileManager.createTempFile(filename_hint);
                ByteBuffer src = b.duplicate();
                fileOutputStream = new FileOutputStream(tempFile.getName());
                FileChannel dest = fileOutputStream.getChannel();
                src.position(offset).limit(offset + len);
                dest.write(src.slice());
                path = tempFile.getName();
            } catch (Exception e) { // Catch exception if any
                throw new Error(e); // we won't recover, so throw an error
            } finally {
                AppUtils.safeClose(fileOutputStream);
            }
        }
        return path;
    }

    public final void setCharset(String charset) {
        if (StringUtils.isBlank(charset)) {
            this.charset = "UTF-8";
        } else {
            this.charset = charset;
        }
    }

    public final void setChannel(SocketChannel channel) {
        this.channel = channel;
    }

    private void decodeHeader(BufferedReader in, Map<String, String> pre, Map<String, List<String>> parms,
        Map<String, String> headers) throws ResponseException {
        try {
            // Read the request line
            String inLine = in.readLine();
            if (inLine == null) {
                return;
            }

            StringTokenizer st = new StringTokenizer(inLine);
            if (!st.hasMoreTokens()) {
                throw new ResponseException(Status.BAD_REQUEST,
                    "BAD REQUEST: Syntax error. Usage: GET /example/file.html");
            }

            pre.put("method", st.nextToken());

            if (!st.hasMoreTokens()) {
                throw new ResponseException(Status.BAD_REQUEST,
                    "BAD REQUEST: Missing URI. Usage: GET /example/file.html");
            }

            String uri = st.nextToken();

            // Decode parameters from the URI
            int qmi = uri.indexOf('?');
            if (qmi >= 0) {
                decodeParms(uri.substring(qmi + 1), parms);
                uri = decodePercent(uri.substring(0, qmi), this.charset);
            } else {
                uri = decodePercent(uri, this.charset);
            }

            // If there's another token, its protocol version,
            // followed by HTTP headers.
            // NOTE: this now forces header names lower case since they are
            // case insensitive and vary by client.
            if (st.hasMoreTokens()) {
                protocolVersion = st.nextToken();
            } else {
                protocolVersion = "HTTP/1.1";
                System.out.println(Level.FINE + "no protocol version specified, strange. Assuming HTTP/1.1.");
            }
            String line = in.readLine();
            while (line != null && !line.trim().isEmpty()) {
                int p = line.indexOf(':');
                if (p >= 0) {
                    headers.put(line.substring(0, p).trim().toLowerCase(Locale.US), line.substring(p + 1).trim());
                }
                line = in.readLine();
            }

            pre.put("uri", uri);
        } catch (IOException ioe) {
            throw new ResponseException(Status.INTERNAL_ERROR,
                "SERVER INTERNAL ERROR: IOException: " + ioe.getMessage(), ioe);
        }
    }

    private void decodeParms(String parms, Map<String, List<String>> p) {
        if (parms == null) {
            this.queryParameterString = "";
            return;
        }

        this.queryParameterString = parms;
        StringTokenizer st = new StringTokenizer(parms, "&");
        while (st.hasMoreTokens()) {
            String e = st.nextToken();
            int sep = e.indexOf('=');
            String key = null;
            String value = null;

            if (sep >= 0) {
                key = decodePercent(e.substring(0, sep), this.charset).trim();
                value = decodePercent(e.substring(sep + 1), this.charset);
            } else {
                key = decodePercent(e, this.charset).trim();
                value = "";
            }

            List<String> values = p.get(key);
            if (values == null) {
                values = new ArrayList<String>();
                p.put(key, values);
            }

            values.add(value);
        }
    }

    private static String decodePercent(String str) {
        return decodePercent(str, null);
    }

    public static String decodePercent(String str, String charset) {
        if (StringUtils.isBlank(charset)) {
            charset = "UTF8";
        }

        String decoded = null;
        try {
            decoded = URLDecoder.decode(str, charset);
        } catch (UnsupportedEncodingException ignored) {
            ignored.printStackTrace();
            System.out.println(Level.WARNING + "Encoding not supported, ignored");
        }
        return decoded;
    }

    private void decodeMultipartFormData(ContentType contentType, ByteBuffer fbuf, Map<String, List<String>> parms,
        Map<String, String> files) throws ResponseException {
        int pcount = 0;
        try {
            int[] boundaryIdxs = getBoundaryPositions(fbuf, contentType.getBoundary().getBytes());
            if (boundaryIdxs.length < 2) {
                throw new ResponseException(Status.BAD_REQUEST,
                    "BAD REQUEST: Content type is multipart/form-data but contains less than two boundary strings.");
            }

            multipartFileContentType = new HashMap<>();
            byte[] partHeaderBuff = new byte[MAX_HEADER_SIZE];
            for (int boundaryIdx = 0; boundaryIdx < boundaryIdxs.length - 1; boundaryIdx++) {
                fbuf.position(boundaryIdxs[boundaryIdx]);
                int len = (fbuf.remaining() < MAX_HEADER_SIZE) ? fbuf.remaining() : MAX_HEADER_SIZE;
                fbuf.get(partHeaderBuff, 0, len);
                String charset = StringUtils.isBlank(this.charset) ? contentType.getEncoding() : this.charset;
                BufferedReader in = new BufferedReader(
                    new InputStreamReader(new ByteArrayInputStream(partHeaderBuff, 0, len), Charset.forName(charset)),
                    len);

                int headerLines = 0;
                // First line is boundary string
                String mpline = in.readLine();
                headerLines++;
                if (mpline == null || !mpline.contains(contentType.getBoundary())) {
                    throw new ResponseException(Status.BAD_REQUEST,
                        "BAD REQUEST: Content type is multipart/form-data but chunk does not start with boundary.");
                }

                String partName = null, fileName = null, partContentType = null;
                mpline = in.readLine();
                headerLines++;
                while (mpline != null && mpline.trim().length() > 0) {
                    Matcher matcher = HttpConstants.CONTENT_DISPOSITION_PATTERN.matcher(mpline);
                    if (matcher.matches()) {
                        String attributeString = matcher.group(2);
                        matcher = HttpConstants.CONTENT_DISPOSITION_ATTRIBUTE_PATTERN.matcher(attributeString);
                        while (matcher.find()) {
                            String key = matcher.group(1);
                            if ("name".equalsIgnoreCase(key)) {
                                partName = matcher.group(2);
                            } else if ("filename".equalsIgnoreCase(key)) {
                                fileName = matcher.group(2);
                                // add these two line to support multiple
                                // files uploaded using the same field Id
                                if (!fileName.isEmpty()) {
                                    partName = this.getKey(parms, partName);
                                }
                            }
                        }
                    }
                    matcher = HttpConstants.CONTENT_TYPE_PATTERN.matcher(mpline);
                    if (matcher.matches()) {
                        partContentType = matcher.group(2).trim();
                    }
                    mpline = in.readLine();
                    headerLines++;
                }
                int partHeaderLength = 0;
                while (headerLines-- > 0) {
                    partHeaderLength = scipOverNewLine(partHeaderBuff, partHeaderLength);
                }
                // Read the part data
                if (partHeaderLength >= len - 4) {
                    throw new ResponseException(Status.INTERNAL_ERROR,
                        "Multipart header size exceeds MAX_HEADER_SIZE.");
                }
                int partDataStart = boundaryIdxs[boundaryIdx] + partHeaderLength;
                int partDataEnd = boundaryIdxs[boundaryIdx + 1] - 4;

                fbuf.position(partDataStart);

                List<String> values = parms.get(partName);
                if (values == null) {
                    values = new ArrayList<String>();
                    parms.put(partName, values);
                }

                if (partContentType == null) {
                    // Read the part into a string
                    byte[] data_bytes = new byte[partDataEnd - partDataStart];
                    fbuf.get(data_bytes);

                    values.add(new String(data_bytes, charset));
                } else {
                    // Read it into a file
                    String path = saveTmpFile(fbuf, partDataStart, partDataEnd - partDataStart, fileName);
                    String pn = partName;
                    files.put(this.getKey(files, partName), path);
                    values.add(fileName);
                    multipartFileContentType.put(pn, partContentType);
                }
            }
        } catch (ResponseException re) {
            throw re;
        } catch (Exception e) {
            throw new ResponseException(Status.INTERNAL_ERROR, e.toString());
        }
    }

    private String getKey(Map<String, ?> map, String key) {
        if (!map.containsKey(key)) {
            return key;
        } else {
            int count = 2;
            while (map.containsKey(key + count)) {
                count++;
            }
            return key + count;
        }
    }

    private int[] getBoundaryPositions(ByteBuffer b, byte[] boundary) {
        int[] res = new int[0];
        if (b.remaining() < boundary.length) {
            return res;
        }

        int search_window_pos = 0;
        byte[] search_window = new byte[4 * 1024 + boundary.length];

        int first_fill = (b.remaining() < search_window.length) ? b.remaining() : search_window.length;
        b.get(search_window, 0, first_fill);
        int new_bytes = first_fill - boundary.length;

        do {
            // Search the search_window
            for (int j = 0; j < new_bytes; j++) {
                for (int i = 0; i < boundary.length; i++) {
                    if (search_window[j + i] != boundary[i]) {
                        break;
                    }
                    if (i == boundary.length - 1) {
                        // Match found, add it to results
                        int[] new_res = new int[res.length + 1];
                        System.arraycopy(res, 0, new_res, 0, res.length);
                        new_res[res.length] = search_window_pos + j;
                        res = new_res;
                    }
                }
            }
            search_window_pos += new_bytes;

            // Copy the end of the buffer to the start
            System.arraycopy(search_window, search_window.length - boundary.length, search_window, 0, boundary.length);

            // Refill search_window
            new_bytes = search_window.length - boundary.length;
            new_bytes = (b.remaining() < new_bytes) ? b.remaining() : new_bytes;
            b.get(search_window, boundary.length, new_bytes);
        } while (new_bytes > 0);

        return res;
    }

    private int scipOverNewLine(byte[] partHeaderBuff, int index) {
        while (partHeaderBuff[index] != '\n') {
            index++;
        }
        return ++index;
    }

    public String getUri() {
        return this.uri;
    }

    public Method getMethod() {
        return this.method;
    }

    public Map<String, String> getHeaders() {
        this.headers.forEach((k, v) -> {
            if ("Accept-Encoding".equalsIgnoreCase(k)) {
                this.headers.replace(k, "gzip");
            }
        });

        return this.headers;
    }

    public final Map<String, List<String>> getParameters() {
        return this.parms;
    }

    public String getQueryParameterString() {
        return this.queryParameterString;
    }

    public ContentType getContentType() {
        return this.contentType;
    }

    public String getRemoteIpAddress() {
        return this.remoteIp;
    }

    public String getRemoteHostName() {
        return this.remoteHostname;
    }

    public ByteBuffer getMultipart() {
        return this.multipart;
    }

    public Map<String, String> getMultipartFileContentType() {
        return this.multipartFileContentType;
    }
}
